還記得第一次接手別人寫的程式碼嗎?那種「這是什麼?」的困惑、「為什麼要這樣寫?」的疑問,以及「我該從哪裡開始改?」的無助感。每個開發者都有過這樣的經歷。
經過前九天的學習,我們掌握了 TDD 的基本工具。今天來學習「重構與測試」:在不改變程式外部行為的前提下,改善程式碼內部結構。這就像整理房間一樣,外觀看起來還是同一個房間,但內部變得井然有序、使用起來更方便。
有了測試作為安全網,重構就變得安全而有信心。測試會告訴你:「重構是否保持了原有的行為」。這就像走鋼索時下面有安全網,讓你可以大膽前進。
重構之旅啟程!
├── 第一站:理解重構的本質
├── 第二站:測試驅動的重構實戰
├── 第三站:掌握重構技巧
├── 第四站:重構最佳實踐
└── 終點站:10天學習總結與回顧
今天你將學會:
重構是透過小步驟改善程式碼結構,同時保持程式的外部行為不變。Martin Fowler 在《Refactoring》一書中說:「重構是在不改變軟體可觀察行為的前提下,改善其內部結構」。
很多人常把重構和重寫搞混:
特性 | 重構 | 重寫 |
---|---|---|
改變外部行為 | ❌ 否 | ✅ 可能 |
需要測試保護 | ✅ 必須 | ⚠️ 不一定 |
風險程度 | 低 | 高 |
進行方式 | 小步驟 | 大範圍 |
時間投入 | 持續進行 | 一次性 |
提升可讀性:讓程式碼更容易理解
減少重複:遵循 DRY (Don't Repeat Yourself) 原則
提升維護性:修改和擴展更容易
降低複雜度:簡化複雜的邏輯
三法則(Rule of Three):
重構的黃金法則:在重構之前,你必須有穩固的測試。沒有測試的重構是危險的,就像沒有安全帶就開車一樣。
1. 確認測試都是綠燈 ✅
2. 執行小步驟重構 🔧
3. 執行測試驗證 🧪
4. 如果測試失敗,立即回復 ↩️
5. 重複直到完成 🔄
讓我們透過實際案例來體驗重構的過程:
建立 app/Services/Calculator.php
<?php
namespace App\Services;
use InvalidArgumentException;
class Calculator
{
public function calculate(float $a, float $b, string $operation): float
{
if ($operation === 'add') {
return $a + $b;
} elseif ($operation === 'subtract') {
return $a - $b;
} elseif ($operation === 'multiply') {
return $a * $b;
} elseif ($operation === 'divide') {
if ($b === 0.0) {
throw new InvalidArgumentException('Cannot divide by zero');
}
return $a / $b;
} else {
throw new InvalidArgumentException('Unknown operation');
}
}
}
建立 tests/Unit/Day10/CalculatorBeforeTest.php
<?php
use App\Services\Calculator;
describe('Calculator - Before Refactor', function () {
beforeEach(function () {
$this->calculator = new Calculator();
});
it('performs addition', function () {
expect($this->calculator->calculate(5.0, 3.0, 'add'))->toBe(8.0);
});
it('performs subtraction', function () {
expect($this->calculator->calculate(5.0, 3.0, 'subtract'))->toBe(2.0);
});
it('throws error when dividing by zero', function () {
expect(fn() => $this->calculator->calculate(5.0, 0.0, 'divide'))
->toThrow(InvalidArgumentException::class);
});
});
現在讓我們執行重構,將 if-elseif 結構改為更優雅的 match 表達式(PHP 8 新特性):
更新 app/Services/Calculator.php
<?php
namespace App\Services;
use InvalidArgumentException;
class Calculator
{
public function calculate(float $a, float $b, string $operation): float
{
return match ($operation) {
'add' => $this->add($a, $b),
'subtract' => $this->subtract($a, $b),
'multiply' => $this->multiply($a, $b),
'divide' => $this->divide($a, $b),
default => throw new InvalidArgumentException('Unknown operation')
};
}
private function add(float $a, float $b): float
{
return $a + $b;
}
private function subtract(float $a, float $b): float
{
return $a - $b;
}
private function multiply(float $a, float $b): float
{
return $a * $b;
}
private function divide(float $a, float $b): float
{
if ($b === 0.0) {
throw new InvalidArgumentException('Cannot divide by zero');
}
return $a / $b;
}
}
建立 tests/Unit/Day10/CalculatorAfterTest.php
<?php
use App\Services\Calculator;
describe('Calculator - After Refactor', function () {
beforeEach(function () {
$this->calculator = new Calculator();
});
it('all operations still work correctly', function () {
expect($this->calculator->calculate(5.0, 3.0, 'add'))->toBe(8.0);
expect($this->calculator->calculate(5.0, 3.0, 'subtract'))->toBe(2.0);
expect($this->calculator->calculate(5.0, 3.0, 'multiply'))->toBe(15.0);
expect($this->calculator->calculate(6.0, 2.0, 'divide'))->toBe(3.0);
});
it('error handling preserved', function () {
expect(fn() => $this->calculator->calculate(5.0, 0.0, 'divide'))
->toThrow(InvalidArgumentException::class, 'Cannot divide by zero');
expect(fn() => $this->calculator->calculate(5.0, 3.0, 'unknown'))
->toThrow(InvalidArgumentException::class, 'Unknown operation');
});
});
// 重構前:長方法
function processOrder($order) {
// 驗證和計算邏輯混在一起
if (!$order->customer) throw new Exception('Customer required');
if (!$order->items || count($order->items) == 0) throw new Exception('Items required');
$total = 0;
foreach ($order->items as $item) {
$total += $item->price * $item->quantity;
}
if ($order->customer->type === 'VIP') {
$total *= 0.9;
}
$order->total = $total;
$order->status = 'processed';
}
// 重構後:提取方法
function processOrder($order) {
validateOrder($order);
$total = calculateTotal($order);
$discountedTotal = applyDiscount($total, $order->customer);
updateOrder($order, $discountedTotal);
}
將複雜表達式提取為有意義的變數名稱:
// 重構前:難以理解的複雜表達式
if ($user->age >= 18 && $user->hasVerifiedEmail() && $user->paymentMethods->count() > 0) {
// 處理邏輯
}
// 重構後:使用有意義的變數
$isAdult = $user->age >= 18;
$hasVerifiedAccount = $user->hasVerifiedEmail();
$hasPaymentMethod = $user->paymentMethods->count() > 0;
if ($isAdult && $hasVerifiedAccount && $hasPaymentMethod) {
// 處理邏輯
}
建立 tests/Unit/Day10/RemoveDuplicationTest.php
<?php
use App\Services\UserService;
describe('Remove Duplication Refactoring', function () {
// 重構前:重複的驗證邏輯
class UserServiceBefore {
public function updateEmail($userId, $email) {
if (!$userId || trim($userId) === '') {
throw new InvalidArgumentException('Invalid user ID');
}
if (!$email || !str_contains($email, '@')) {
throw new InvalidArgumentException('Invalid email');
}
// 更新邏輯...
}
public function updatePassword($userId, $password) {
if (!$userId || trim($userId) === '') {
throw new InvalidArgumentException('Invalid user ID');
}
if (!$password || strlen($password) < 8) {
throw new InvalidArgumentException('Invalid password');
}
// 更新邏輯...
}
}
// 重構後:提取共用驗證
class UserServiceAfter {
private function validateUserId($userId) {
if (!$userId || trim($userId) === '') {
throw new InvalidArgumentException('Invalid user ID');
}
}
public function updateEmail($userId, $email) {
$this->validateUserId($userId);
if (!$email || !str_contains($email, '@')) {
throw new InvalidArgumentException('Invalid email');
}
// 更新邏輯...
}
public function updatePassword($userId, $password) {
$this->validateUserId($userId);
if (!$password || strlen($password) < 8) {
throw new InvalidArgumentException('Invalid password');
}
// 更新邏輯...
}
}
it('validates user ID consistently', function () {
$serviceBefore = new UserServiceBefore();
$serviceAfter = new UserServiceAfter();
// 測試無效的 userId
expect(fn() => $serviceBefore->updateEmail('', 'test@test.com'))
->toThrow(InvalidArgumentException::class, 'Invalid user ID');
expect(fn() => $serviceAfter->updateEmail('', 'test@test.com'))
->toThrow(InvalidArgumentException::class, 'Invalid user ID');
// 測試無效的 email
expect(fn() => $serviceBefore->updateEmail('user123', 'invalid'))
->toThrow(InvalidArgumentException::class, 'Invalid email');
expect(fn() => $serviceAfter->updateEmail('user123', 'invalid'))
->toThrow(InvalidArgumentException::class, 'Invalid email');
});
});
不要一次性大重構,而是小步驟進行:
🔴 錯誤示範:
「我要花三天時間重構整個模組」
🟢 正確做法:
「我每次只重構一個方法,確保測試通過後再繼續」
執行步驟:
確保重構前後行為一致:
# 重構工作流程
$ pest tests/Unit/Day10/ # ✅ 確認測試綠燈
$ # 執行重構...
$ pest tests/Unit/Day10/ # 🧪 驗證行為未變
$ git commit -m "refactor: extract method for validation"
重構時保持函數介面不變,避免破壞現有程式碼:
// ❌ 破壞性變更
getTotal() // 原本
calculateTotal() // 直接改名
// ✅ 向後相容
public function getTotal() {
// 標記為過時,但保持可用
trigger_error('getTotal() is deprecated, use calculateTotal() instead', E_USER_DEPRECATED);
return $this->calculateTotal();
}
public function calculateTotal() {
// 新的實作
}
適合重構的時機:
不適合重構的時機:
透過今天的學習,我們掌握了:
階段 | 核心技能 | 實戰能力 |
---|---|---|
入門 (Day 1-3) | ✅ Pest 框架✅ 基本斷言✅ 紅綠重構 | 能寫簡單的單元測試 |
基礎 (Day 4-6) | ✅ 測試組織✅ 生命週期✅ 參數化測試 | 能組織大型測試套件 |
深化 (Day 7-9) | ✅ Mock/Stub✅ 例外測試✅ 覆蓋率分析 | 能測試複雜場景 |
整合 (Day 10) | ✅ 安全重構✅ 程式碼品質✅ 持續改進 | 能維護高品質程式碼 |
今天我們學會了測試驅動的重構,這是 TDD 循環中「重構」步驟的深入實踐。有了測試作為安全網,我們可以大膽地改善程式碼結構,讓系統變得更好。
重構不是一次性的大工程,而是持續的小改進。就像園丁修剪花園,每天做一點,最終會有一個美麗的花園。
第一階段的基礎訓練圓滿完成!記住 TDD 的精髓:紅 → 綠 → 重構。
明天我們將開始「Roman Numeral Kata」實戰,用 TDD 的方式解決真實的程式問題!